Day 4 我們體驗了 Playwright 內建的 Codegen 錄製工具,看到它能在我們操作畫面的同時,自動生成 page.getByRole(...) 等程式碼,前面有簡單解釋這幾行程式碼在做什麼,但沒有仔細說明什麼是 locator。
這篇我們就來深入了解 Playwright 的核心概念之一:Locator(定位器),另外因為我們使用的目標app是使用 Material-UI,所以會順便介紹遇到 Material-UI 時該如何定位元素,最後我們會在 dashboard 頁面上實際寫兩支測試,分別用不同的方式抓元素,然後說明為什麼官方會推薦使用 data-testid。
在 Playwright 中,Locator 不是你要尋找的「那個元素」,而是一個描述怎麼找到那個元素的物件的方法。你可以把它想成一張「尋人啟事」,上面寫著要找的條件,但還沒有真的去找。
// 這一行不會去頁面上找任何東西,也不會報錯
const signInButton = page.getByRole('button', { name: 'Sign in' });
// 真正去頁面上找,是在你對它做動作或斷言的時候
await signInButton.click();
Playwright 的 Locator 物件具有**惰性求值 (Lazy Evaluation)**的特性,也就是在建立時,Playwright 不會立刻去 DOM 搜尋元素,只有在真正執行操作(如 click())或斷言(如 expect().toBeVisible())時才會觸發搜尋,這個設計具有以下幾個好處:
locator.click() 或 locator.fill() 時,Playwright 才會依照這張尋人啟事去頁面上找元素,找不到就等一下再找,直到元素出現、可見(Visible)、以及可以被點擊(Enabled)為止(預設等 30 秒),所以我們幾乎不需要自己寫 waitForSelector 或 sleep 去處理元素還沒render好就去尋找的情況。此外 Playwright 還有一個特性是嚴格模式 (Strict Mode),若一個 Locator 匹配到多個元素,且你試圖執行單一操作(如 click()),Playwright 會直接拋出錯誤,避免誤點錯元素,這樣可以強迫把定位條件寫清楚,確保測試的穩定性。
Playwright 官方提倡「站在真實使用者的角度」來尋找元素,因此優先推薦語意化(Semantic)的 Locator API。以下是常用的定位方法:
page.getByRole(role, options)(最推薦)依據 HTML5 的 ARIA 語意角色(ARIA role或是label)來定位元素,這是官方最推薦的定位方式。
getByRole('button', { name: 'Sign in' })
getByRole('textbox', { name: 'Username' })
getByRole('heading', { name: 'Welcome' })
page.getByLabel(text)專門用來選取與 <label> 標籤關聯的表單輸入框。
page.getByLabel('Password')
page.getByText(text)透過頁面上顯示的文字內容來搜尋元素(適合尋找段落、提示文字或靜態標籤等非互動性的文字)。
page.getByText('Monthly Revenue')
page.getByTestId(id)(防線等級的最穩定選取方式)透過專門為測試設定的 HTML 屬性(預設為 data-testid)來選取。當 UI 文字經常隨語系或需求變更時,這個做法是最不會受 UI 改版影響的策略。
page.getByTestId('welcome-card')
其他備用 Locator:
getByPlaceholder()、getByAltText()、getByTitle(),以及常見的locator('css-selector')或locator('xpath')。官方建議儘量少用長串的 CSS/XPath來進行定位,因為只要 HTML 結構稍有調整,測試就容易壞掉。
| 方法 | 依據 | 適合的情境 |
|---|---|---|
getByRole(role, { name }) |
ARIA role + 無障礙名稱 | 按鈕、連結、輸入框、標題,最推薦的語意化寫法 |
getByLabel(text) |
表單的 label | 有 <label> 綁定的表單欄位 |
getByPlaceholder(text) |
placeholder 屬性 | 沒有 label、只有提示文字的欄位 |
getByText(text) |
元素的文字內容 | 非互動性的文字,例如提示訊息 |
getByAltText(text) |
圖片的 alt | 圖片 |
getByTitle(text) |
title 屬性 | 有 tooltip 的元素 |
getByTestId(id) |
data-testid 屬性 |
上面幾種都不好用、或想要一個穩定的定位點時 |
這裡有兩個小細節提醒 :
getByRole 的 name 比對預設是不分大小寫、而且會忽略前後空白。所以 getByRole('button', { name: 'sign in' }) 跟 { name: 'Sign in' } 效果一樣,如果要求完全一致要加上 exact: true。
getByText 的字串比對預設是「不分大小寫的子字串比對」。這代表 getByText('sign') 也會抓到 Sign in。同樣要加 exact: true 才會變成完整比對而且區分大小寫。
準備要開始寫測試了,但有一個問題是要怎麼知道想要抓的元素的role是什麼呢?這邊提供幾個方法,可以看狀況選擇使用:
第一種最簡單,你只要根據看到的 UI 元素來判斷就好。例如:
| 你看到的東西 | role |
|---|---|
| 按鈕 | button |
| 連結 | link |
| 文字輸入框 | textbox |
| 數字輸入框 | spinbutton |
| 勾選框、開關 | checkbox |
| 下拉選單 | combobox(展開後的每個選項是 option) |
| 標題文字 | heading |
| 分頁籤 | tab |
| 側邊選單項目 | menuitem |
name 就是元素上顯示的文字,或是它旁邊那個標籤的文字。所以畫面上有一顆寫著 Sign in 的按鈕,直接寫 getByRole('button', { name: 'Sign in' })。如果寫錯了也沒關係,當你把測試跑起來之後,Playwright 會明確告訴你「找不到元素」或是「找到兩個」,這時候看到錯誤訊息再回頭調整就好。
第二種是猜不到的時候,開瀏覽器的開發者工具看。 在 Chrome DevTools 的 Elements 面板選中元素,右側欄有一個 Accessibility 分頁,裡面的 Computed Properties 會直接列出瀏覽器算出來的 Name 跟 Role。不用另外裝任何工具或是先寫好測試,直接在瀏覽器上確認就好。
第三種方法是如果前兩種都還不確定的話,就用 Codegen 的 Pick locator。 Day 4 我們用 Codegen 錄過一段操作,其實它還可以當成元素檢查器用:
npx playwright codegen http://localhost:8000/

跳出來的 Playwright Inspector 上方有一顆 Pick locator(如圖),點下去之後滑鼠移到哪個元素上,Inspector 就即時告訴你該怎麼抓它。下方的 Locator 分頁會給你一段可以直接複製貼上的 Playwright 語法,旁邊的 Aria 分頁則會顯示這個元素的無障礙結構。
比起前面兩個方法,Codegen 的好處是它直接給你可以貼進測試的完整寫法,而不是只告訴你 role 叫什麼。缺點是要另外開一個視窗。
我們的練習網站 react-admin demo 是採用 Material-UI (MUI) 框架開發的。MUI 的元件雖然美觀且功能豐富,但很多元件的實際 DOM 結構跟你看到的東西不太一樣——看起來是 A、實際上是 B,所以這邊先簡單說一下用這個網站寫 E2E 測試常會遇到的幾個「眉角」需要注意:
div 或 span。如果用傳統的 CSS 結構定位(例如 div > div > input),極易脆化(Flaky)。<select>,點擊後選單選項(Option)會以彈窗(Popover)型態被渲染在 <body> 的最外層。選取時建議先點擊下拉選單觸發按鈕,再用 page.getByRole('option', { name: '選單項目' }) 選取選項。MuiButton-root css-1jy569b-MuiFormLabel-root。後面的 css-1jy569b 是動態編譯生成的 hash,版本升級或元件重新編譯後就會改變,千萬不要直接拿來當 CSS Selector。所以為了更方便後續示範測試程式碼,我們後續的程式會以Codegen抓出來的locator為主。
現在我們開啟 demo 後台(預設連線為 http://localhost:8000/)。今天我們要實作兩個功能測試:
getByRole 與 getByText 測試 Dashboard 頁面。data-testid 後,再撰寫對應的 testID 測試腳本!在 playwright-tests/tests/ 資料夾下新建 dashboard.spec.ts 檔案,並寫下第一個測試案例:
// playwright-tests/tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Dashboard 頁面元素定位測試', () => {
test.beforeEach(async ({ page }) => {
// 1. 登入系統進入 Dashboard
await page.goto('http://localhost:8000/');
await page.getByRole('textbox', { name: 'Username' }).fill('demo');
await page.getByRole('textbox', { name: 'Password' }).fill('demo');
await page.getByRole('button', { name: 'Sign in' }).click();
});
test('使用 getByRole 與 getByText 驗證 Dashboard 卡片與標題', async ({ page }) => {
// 驗證歡迎標題 (Role)
const welcomeHeader = page.getByRole('heading', {
name: 'Welcome to the react-admin e-commerce demo',
});
await expect(welcomeHeader).toBeVisible();
// 驗證指標卡片標題 (Text)
const monthlyRevenueLabel = page.getByText('Monthly Revenue');
await expect(monthlyRevenueLabel).toBeVisible();
});
});
登入之後的 dashboard 上有兩張數字卡片,左邊是 Monthly Revenue、右邊是 New Orders,兩張都可以點,點下去會導到訂單列表。
用剛剛講的 Codegen Pick locator 點一下這張卡片,Inspector 的 Locator 分頁會顯示他產生的 locator:
getByRole('link', { name: 'Monthly Revenue $' })
可以看到整張卡片其實是一個 link,它的無障礙名稱是裡面的標題跟金額單位符號。
下面是今天要寫的測試程式碼:
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:8000/');
await page.getByRole('textbox', { name: 'Username' }).fill('demo');
await page.getByRole('textbox', { name: 'Password' }).fill('demo');
await page.getByRole('button', { name: 'Sign in' }).click();
});
test('Monthly Revenue 卡片顯示金額,點擊後導向訂單列表', async ({ page }) => {
const revenueCard = page.getByRole('link', { name: /Monthly Revenue/ });
await expect(revenueCard).toBeVisible();
await expect(revenueCard).toContainText(/\p{Sc}[\d,]+/u);
await revenueCard.click();
await expect(page).toHaveURL(/#\/orders/);
});
beforeEach 是 Playwright Test 的 hook,每支測試跑之前都會先執行一次,我們把之前寫的登入的邏輯從本來的測試搬到這裡統一處理,這樣我們不需要在這個檔案中的每個測試開頭都寫一次登入的邏輯,讓程式碼更簡潔。
這裡有兩個地方特別要注意。
第一,name 用的是正規表達式 /Monthly Revenue/ 而不是完整字串。因為這張卡片的名稱包含金額跟單位符號,如果寫 { name: 'Monthly Revenue $' },未來當單位符號變動,這個 locator 就會壞掉。用正規表達式做部分比對,測試就只綁定在「標題」這個穩定的部分上。
第二,斷言金額用的是格式而不是數值。toContainText(/\p{Sc}[\d,]+/u) 驗證的是「有顯示一個幣值單位開頭的數字」。我們使用了正規表達式中的 Unicode 屬性 \p{Sc}(Currency Symbol)並搭配 u flag,這樣就能匹配任何幣別符號(如 $、€、£ 等),而不必限制在特定的錢字號,而金額除非是一個固定值,不然不建議寫死在測試程式中。
第二支測試改成用 getByTestId。react-admin demo 的原始碼裡面本來沒有任何 data-testid,所以我們得自己加上去。
dashboard 的兩張數字卡片共用同一個元件 demo/src/dashboard/CardWithIcon.tsx,我們在它的 props 加一個 testId,把它掛到最外層的 Card 上,順便也給裡面顯示數值的那個 Typography 一個衍生的 test id:
// demo/src/dashboard/CardWithIcon.tsx
interface Props {
icon: FC<any>;
to: To;
title?: string;
subtitle?: ReactNode;
children?: ReactNode;
testId?: string; // 新增
}
const CardWithIcon = ({
icon,
title,
subtitle,
to,
children,
testId, // 新增
}: Props) => (
<Card
data-testid={testId} // 新增
sx={{ /* ...原本的樣式不動... */ }}
>
{/* ...中略... */}
<Typography
variant="h5"
component="h2"
data-testid={testId ? `${testId}-value` : undefined} // 新增
>
{subtitle || ' '}
</Typography>
{/* ...中略... */}
</Card>
);
然後在兩個使用它的地方把 testId 傳進去:
// demo/src/dashboard/MonthlyRevenue.tsx
<CardWithIcon
to="/orders"
icon={DollarIcon}
title={translate('pos.dashboard.monthly_revenue')}
subtitle={value}
testId="monthly-revenue"
/>
// demo/src/dashboard/NbNewOrders.tsx
<CardWithIcon
to="/orders"
icon={ShoppingCartIcon}
title={translate('pos.dashboard.new_orders')}
subtitle={value}
testId="new-orders"
/>
testId 設成 optional,所以其他還沒改的地方(例如 Pending Reviews、New Customers 那兩張卡)不會受影響。
改完之後 vite 會自動熱更新,測試就可以這樣寫:
test('New Orders 卡片顯示本月新訂單數', async ({ page }) => {
const newOrdersCard = page.getByTestId('new-orders');
await expect(newOrdersCard).toBeVisible();
await expect(page.getByTestId('new-orders-value')).toHaveText(/^\d+$/);
});
順帶一提,getByTestId 預設抓的屬性名稱就是 data-testid。如果你的專案已經有自己的慣例(像是 data-test 或 data-cy),可以在 playwright.config.ts 裡改:
export default defineConfig({
use: {
testIdAttribute: 'data-test',
},
});
現在執行 npx playwright test dashboard.spec.ts --headed,就會看到兩筆測試都順利通過!
data-testid?在實際團隊開發中,雖然 getByRole 和 getByText 很接近真實使用者的閱讀習慣,但我們強烈推薦在重要元件上補上 data-testid。原因如下:
Sign in 改成 Log in,或者產品支援多國語系切換(英文變繁體中文),或是卡片設計成不可點擊(單純顯示資訊),基於 getByText 或 getByRole 的測試腳本會立刻報錯壞掉。但如果採用 data-testid,不管文案怎麼變,測試依然穩如泰山。data-testid="monthly-revenue-card",測試工程師就能一秒定位,不需再去研究 MUI 的 nested div 結構。data-testid 作為元件合約(Contract)的一部分,前端工程師在重構 UI 或替換樣式庫時,只要保留 data-testid,就能確保自動化測試完全不受影響。今天我們學習了 Playwright 的 Locator 機制:
getByRole、getByText、getByLabel 與 getByTestId)。getByRole 跟 getByTestId 定位,也動手在 demo 的原始碼加了第一個 data-testid
下一篇我們會來學習 Playwright 自動化測試最經典的架構——Page Object Model (POM)!